Чтобы оценить корректность проведения теста, проверим:
Это поможет оценить корректность работы системы распределения пользователей по группам и окончательно определиться с аудиторией теста.
Подключим необходимые для работы библиотеки.
import pandas as pd
import numpy as np
import scipy.stats as st
import math as mth
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
from plotly import graph_objects as go
Настроим отображение данных.
pd.set_option('precision', 2)
Считаем данные в датафреймы.
try:
events = pd.read_csv('datasets/final_ab_events.csv')
marketing_events = pd.read_csv('datasets/ab_project_marketing_events.csv')
new_users = pd.read_csv('datasets/final_ab_new_users.csv')
tests = pd.read_csv('datasets/final_ab_participants.csv')
except:
events = pd.read_csv('/datasets/final_ab_events.csv')
marketing_events = pd.read_csv('/datasets/ab_project_marketing_events.csv')
new_users = pd.read_csv('/datasets/final_ab_new_users.csv')
tests = pd.read_csv('/datasets/final_ab_participants.csv')
Ознакомимся с данными. Изучим общую информацию о таблицах, уникальные значения атрибутов.
Изучим календарь маркетинговых акций.
marketing_events
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| 5 | Black Friday Ads Campaign | EU, CIS, APAC, N.America | 2020-11-26 | 2020-12-01 |
| 6 | Chinese New Year Promo | APAC | 2020-01-25 | 2020-02-07 |
| 7 | Labor day (May 1st) Ads Campaign | EU, CIS, APAC | 2020-05-01 | 2020-05-03 |
| 8 | International Women's Day Promo | EU, CIS, APAC | 2020-03-08 | 2020-03-10 |
| 9 | Victory Day CIS (May 9th) Event | CIS | 2020-05-09 | 2020-05-11 |
| 10 | CIS New Year Gift Lottery | CIS | 2020-12-30 | 2021-01-07 |
| 11 | Dragon Boat Festival Giveaway | APAC | 2020-06-25 | 2020-07-01 |
| 12 | Single's Day Gift Promo | APAC | 2020-11-11 | 2020-11-12 |
| 13 | Chinese Moon Festival | APAC | 2020-10-01 | 2020-10-07 |
display(marketing_events['start_dt'].min())
display(marketing_events['finish_dt'].max())
'2020-01-25'
'2021-01-07'
Имеем список маркетинговый акций, проводившихся в период с 2020-01-25 по 2021-01-07, следовательно, имеются маркетинговые активности в период проведения A/B-теста. Изучим эту информацию в дальнейшем. Пропуски в данных отсутствуют.
Изучим данные о новых пользователях, привлеченных в период проведения A/B-теста.
display(new_users.info())
print('Dataframe keeps {} records by {} attributes\n'. format(new_users.shape[0], new_users.shape[1]))
print('Number of unique items in attributes:')
for col in new_users.columns:
print('\t- number of unique "{}": {}'.format(col, new_users[col].nunique()))
if new_users[col].nunique() < 10:
print('\t\tthey are:')
print('\t\t{}'.format(new_users[col].unique()))
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB
None
Dataframe keeps 61733 records by 4 attributes Number of unique items in attributes: - number of unique "user_id": 61733 - number of unique "first_date": 17 - number of unique "region": 4 they are: ['EU' 'N.America' 'APAC' 'CIS'] - number of unique "device": 4 they are: ['PC' 'Android' 'iPhone' 'Mac']
display(new_users['first_date'].min())
display(new_users['first_date'].max())
'2020-12-07'
'2020-12-23'
Имеем список из 61733 записей о пользователях, зарегистрировавшихся в период с 2020-12-07 по 2020-12-23.
Для каждого пользователя указаны id, дата регистрации, регион и устройство.
Пропуски в данных отсутствуют.
Изучим данные о действиях новых пользователях.
display(events.info())
print('Dataframe keeps {} records by {} attributes\n'. format(events.shape[0], events.shape[1]))
print('Number of unique items in attributes:')
for col in events.columns:
print('\t- number of unique "{}": {}'.format(col, events[col].nunique()))
if events[col].nunique() < 10:
print('\t\tthey are:')
print('\t\t{}'.format(events[col].unique()))
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB
None
Dataframe keeps 440317 records by 4 attributes Number of unique items in attributes: - number of unique "user_id": 58703 - number of unique "event_dt": 267268 - number of unique "event_name": 4 they are: ['purchase' 'product_cart' 'product_page' 'login'] - number of unique "details": 4 they are: [ 99.99 9.99 4.99 499.99 nan]
display(events['event_dt'].min())
display(events['event_dt'].max())
'2020-12-07 00:00:33'
'2020-12-30 23:36:33'
Имеем список событий четырех типов: 'purchase' (покупка), 'product_cart' (страница корзины), 'product_page' (страница товара), 'login'(вход).
Всего - 440317 записей для 58703 пользователей в период с 2020-12-07 по 2020-12-30, что не соответствует ТЗ (тест должен был проводиться до 2021-01-04). Подробнее изучим эти данные позднее.
'details' - дополнительные данные о событии 'purchase' - стоимость покупки в долларах. В данном поле имеются пропуски. Изучим их позднее.
Изучим данные о проводимых A/B-тестах и их участниках.
tests.info()
print('\nDataframe keeps {} records by {} attributes\n'. format(tests.shape[0], tests.shape[1]))
print('Number of unique items in attributes:')
for col in tests.columns:
print('\t- number of unique "{}": {}'.format(col, tests[col].nunique()))
if tests[col].nunique() < 10:
print('\t\tthey are:')
print('\t\t{}'.format(tests[col].unique()))
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB Dataframe keeps 18268 records by 3 attributes Number of unique items in attributes: - number of unique "user_id": 16666 - number of unique "group": 2 they are: ['A' 'B'] - number of unique "ab_test": 2 they are: ['recommender_system_test' 'interface_eu_test']
Имеем 18268 записей о пользователях, принявших участие в двух A/B-тестах: 'recommender_system_test' и 'interface_eu_test'. Среди них - 16666 уникальных записей, следовательно имеются пересечения между тестами или группами тестов. Изучим это в дальнейшем. Пропуски в данных отсутствуют.
Имеем все необходимые для анализа данные. В данных имеется ряд артефактов, а также на первый взгляд имеются несоответсвия требованиям ТЗ. Оценим их позднее после подготовки данных для анализа.
Преобразуем данные о дате/времени в соответствующий тип. Добавим в логи столбец с датой события.
events['event_dt'] = pd.to_datetime(events['event_dt'])
marketing_events['start_dt'] = pd.to_datetime(marketing_events['start_dt'])
marketing_events['finish_dt'] = pd.to_datetime(marketing_events['finish_dt'])
new_users['first_date'] = pd.to_datetime(new_users['first_date'])
events['date'] = events['event_dt'].dt.normalize()
Преобразуем некоторые строковые данные в категориальный тип для уменьшения объемов памяти, занимаемой датафреймами.
events['event_name'] = events['event_name'].astype('category')
new_users['region'] = new_users['region'].astype('category')
new_users['device'] = new_users['device'].astype('category')
tests['group'] = tests['group'].astype('category')
tests['ab_test'] = tests['ab_test'].astype('category')
Преобразуем тип данных стоимостей покупок во float32, так как такой точности будет достаточно, и это уменьшит объем используемой памяти. Настроим отображение данных.
events['details'] = events['details'].astype('float32')
Проверим корректность преобразований.
display(events['details'].unique())
for df in [events, marketing_events, new_users, tests]:
display(df.info())
display(df.head())
print()
array([ 99.99, 9.99, 4.99, 499.99, nan], dtype=float32)
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null datetime64[ns] 2 event_name 440317 non-null category 3 details 62740 non-null float32 4 date 440317 non-null datetime64[ns] dtypes: category(1), datetime64[ns](2), float32(1), object(1) memory usage: 12.2+ MB
None
| user_id | event_dt | event_name | details | date | |
|---|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 | 2020-12-07 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 | 2020-12-07 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 | 2020-12-07 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 | 2020-12-07 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 | 2020-12-07 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null datetime64[ns] 3 finish_dt 14 non-null datetime64[ns] dtypes: datetime64[ns](2), object(2) memory usage: 576.0+ bytes
None
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null datetime64[ns] 2 region 61733 non-null category 3 device 61733 non-null category dtypes: category(2), datetime64[ns](1), object(1) memory usage: 1.1+ MB
None
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null category 2 ab_test 18268 non-null category dtypes: category(2), object(1) memory usage: 178.8+ KB
None
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
Данные преобразованы корректно.
Оценим количество пропусков в данных.
for df in [events, marketing_events, new_users, tests]:
for col in df.columns:
print('Пропуски в {}: {}'.format(col, df[col].isnull().sum()))
Пропуски в user_id: 0 Пропуски в event_dt: 0 Пропуски в event_name: 0 Пропуски в details: 377577 Пропуски в date: 0 Пропуски в name: 0 Пропуски в regions: 0 Пропуски в start_dt: 0 Пропуски в finish_dt: 0 Пропуски в user_id: 0 Пропуски в first_date: 0 Пропуски в region: 0 Пропуски в device: 0 Пропуски в user_id: 0 Пропуски в group: 0 Пропуски в ab_test: 0
Видим, что пропуски имеются только в поле details датафрейма events. Изучим их подробнее.
events[events['details'].isna()]['event_name'].unique()
['product_cart', 'product_page', 'login'] Categories (3, object): ['product_cart', 'product_page', 'login']
events[events['details'].notna()]['event_name'].unique()
['purchase'] Categories (1, object): ['purchase']
Видим, что пропуски в указанном поле соответствуют событиям 'product_cart' (страница корзины), 'product_page' (страница товара), 'login'(вход). Все события 'purchase' (покупка) имеют заполненное поле details. Следовательно, данные пропуски - особенность сбора данных, и обрабатывать их не нужно.
Проверим наличие явных полных дубликатов.
for df in [events, marketing_events, new_users, tests]:
display(len(df[df.duplicated()]))
0
0
0
0
Явные полные дубликаты отсутствуют.
Убедимся в отсутствии задвоенных записей о новых пользователях.
new_users[new_users['user_id'].duplicated()]['user_id'].count()
0
Дубликатов среди идентификаторов пользователей не найдено.
Изучим дубликаты в данных о тестах.
tests[tests['user_id'].duplicated()]['user_id'].count()
1602
1602 идентификатора пользователя встречаются в данных о тестах более одного раза. Так как полных дубликатов в данной таблице нет, следовательно указанное количество пользователей принимали участие более чем в одном тесте или ошибочно оказались в обеих группах какого-либо из тестов. Проверим эти данные позднее ири оценке корректности проведенного теста.
Проверим соответствует ли техническому заданию количество пользователей в списке с проводимыми тестами:
print(
'Количество участников по данным о тестах: {}'
.format(tests[tests['ab_test'] == 'recommender_system_test']['user_id'].nunique())
)
Количество участников по данным о тестах: 6701
Количество участников удовлетворяет требованиям ТЗ.
Проверим соответствие географии выбранных пользователей:
eu_new_users = new_users[new_users['region'] == 'EU']['user_id']
print(
'Количество новых пользователей из EU за период теста: {}\n'
.format(eu_new_users.nunique()))
print(
'Количество участников из региона EU, попавших в тест: {}\n'
.format(tests[
(tests['ab_test'] == 'recommender_system_test')
& (tests['user_id'].isin(eu_new_users))
]['user_id'].nunique())
)
print(
'Процент пользователей из EU, попавших в тест: {}%'
.format(
round(tests[
(tests['ab_test'] == 'recommender_system_test')
& (tests['user_id'].isin(eu_new_users))
]['user_id'].nunique() / eu_new_users.nunique() * 100, 2)
)
)
Количество новых пользователей из EU за период теста: 46270 Количество участников из региона EU, попавших в тест: 6351 Процент пользователей из EU, попавших в тест: 13.73%
В тест попали 350 пользователей из другого региона. При этом общее число пользователей из EU, попавших в тест, соответствует ТЗ, однако их процент от числа новых пользователей из региона немного не дотягивает до требований ТЗ в 15%.
Отфильтруем участников теста, удалив пользователей из несоответствующего региона.
participants = (
tests[(tests['ab_test'] == 'recommender_system_test')
& (tests['user_id'].isin(eu_new_users))]['user_id']
)
print('Количество участников теста после фильтрации: {}'.format(participants.count()))
Количество участников теста после фильтрации: 6351
Количество участников теста после удаления пользователей из нецелевого региона по-прежнему соответствует требованиям ТЗ.
Проверим предоставленные данные на соответствие временному интервалу проведения теста:
display(events['event_dt'].min())
display(events['event_dt'].max())
Timestamp('2020-12-07 00:00:33')
Timestamp('2020-12-30 23:36:33')
Временной интервал пользовательской активности в предоставленных данных не соответствует ТЗ и ограничен датой 2020-12-30, что связано либо с неполнотой данных, либо с досрочным прекращением теста.
Так как по условиям ТЗ A/B-тест проводился в период с 2020-12-07 по 2021-01-04 для новых пользователей из Европейского региона, зарегистрировавшихся по 2020-12-21, отфильтруем таблицу маркетинговых событий и изучим данные для выбранного региона за выбранный период.
marketing_events_eu = (
marketing_events[
(marketing_events['regions'].str.contains('EU'))
& (marketing_events['finish_dt'] >= '2020-12-07')
& (marketing_events['start_dt'] <= '2020-12-30')
]
)
marketing_events_eu
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
Видим, что в период проведения A/B-теста проводилась маркетинговая кампания, посвященная Новому Году и Рождеству. Провдение маркетинговой акции могло оказать влияние на результат A/B-теста. Так как у нас нет подробных данных о ее механике, руководству проекта стоит самостоятельно оценить ее возможный эффект.
Возможно, с этим связано и досрочное прекращение А/В-теста (данные в логах ограничены датой 2020-12-30).
Проверим, имеются ли пересечения аудитории между группами исследуемого теста.
print(
'Количество пользователей, попавших в обе группы теста: {}'
.format(tests[(tests['ab_test'] == 'interface_eu_test')
& (tests['group'] == 'A')
& (tests['group'] == 'B')
]['user_id'].nunique()
)
)
Количество пользователей, попавших в обе группы теста: 0
Пересечений групп нет. Пользователи разделены на группы корретно.
Ранее мы выяснили, что в данных представлена информация о двух проведенных А/В-тестах. Проверим, имеются ли пересечения аудитории между ними.
other_test_participants = tests[tests['ab_test'] == 'interface_eu_test']['user_id']
print(
'Количество пользователей, попавших в оба теста: {}'
.format(
tests[(tests['user_id'].isin(participants))
& (tests['user_id'].isin(other_test_participants))]['user_id'].nunique()
)
)
Количество пользователей, попавших в оба теста: 1602
1602 пользователя попали в оба теста.
Проверим, как эти пользователи распределены между группами теста.
users_grouped = (
tests[(tests['ab_test'] == 'recommender_system_test')
& (tests['user_id'].isin(participants))]
.groupby('group', as_index=False)
.agg(users=('user_id', 'nunique'))
)
users_grouped['users_distr'] = users_grouped['users'] / users_grouped['users'].sum()
crossed_users_grouped = (
tests[(tests['ab_test'] == 'recommender_system_test')
& (tests['user_id'].isin(participants))
& (tests['user_id'].isin(other_test_participants))
]
.groupby('group', as_index=False)
.agg(cros_users=('user_id', 'nunique'))
)
crossed_users_grouped['cros_users_distr'] = (
crossed_users_grouped['cros_users'] / crossed_users_grouped['cros_users'].sum()
)
users_grouped = users_grouped.merge(crossed_users_grouped, on='group')
users_grouped['cros_users_part'] = users_grouped['cros_users'] / users_grouped['users']
users_grouped
| group | users | users_distr | cros_users | cros_users_distr | cros_users_part | |
|---|---|---|---|---|---|---|
| 0 | A | 3634 | 0.57 | 921 | 0.57 | 0.25 |
| 1 | B | 2717 | 0.43 | 681 | 0.43 | 0.25 |
Участники разделены между группами теста не в равных долях, а в пропорции 57/43.
В каждой из групп четверть участников - пользователи, попавшие одновременно в два теста. Так как эти пользователи разделены между группами в той же пропорции, что и остальные участники, их участие в другом тесте окажет одинаковое влияние на обе группы исследуемого теста.
При проверке данных выявлены следующие проблемы:
Несоответствия были устранены путем подготовки отфильтрованного списка участников теста.
Остальные данные соответствуют требованиям ТЗ:
Также мы убедились, что пользователи корректно разделены на две группы - пересечений между группами теста нет.
Ранее мы установили, что в логах представлены данные о событиях четырех типов: 'login'(вход в сервис), 'product_page' (страница товара), 'product_cart' (страница корзины), 'purchase' (покупка).
Всего - 440317 записей для 58703 пользователей в период с 2020-12-07 по 2020-12-30.
Отфильтруем события, оставив только данные участников теста.
Также учтем, что для целей теста нужно использовать только первые 14 дней активности пользователей.
Добавим в логи данные о группе, к которой относится пользователь.
Оценим получившиеся данные.
new_users['last_date'] = new_users['first_date'] + pd.Timedelta(days=14)
events_filtered = (
events[events['user_id'].isin(participants)]
.merge(tests[['user_id', 'group']], on='user_id')
.merge(new_users[['user_id', 'last_date']], on='user_id')
.query('date <= last_date')
.drop_duplicates()
.reset_index(drop=True)
)
events_filtered.info()
print('\nDataframe keeps {} records by {} attributes\n'. format(events_filtered.shape[0], events_filtered.shape[1]))
print('Number of unique items in attributes:')
for col in events_filtered.columns:
print('\t- number of unique "{}": {}'.format(col, events_filtered[col].nunique()))
if events_filtered[col].nunique() < 10:
print('\t\tthey are:')
print('\t\t{}'.format(events_filtered[col].unique()))
<class 'pandas.core.frame.DataFrame'> RangeIndex: 25539 entries, 0 to 25538 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 25539 non-null object 1 event_dt 25539 non-null datetime64[ns] 2 event_name 25539 non-null category 3 details 3481 non-null float32 4 date 25539 non-null datetime64[ns] 5 group 25539 non-null category 6 last_date 25539 non-null datetime64[ns] dtypes: category(2), datetime64[ns](3), float32(1), object(1) memory usage: 948.2+ KB Dataframe keeps 25539 records by 7 attributes Number of unique items in attributes: - number of unique "user_id": 3481 - number of unique "event_dt": 15231 - number of unique "event_name": 4 they are: ['purchase', 'product_cart', 'login', 'product_page'] Categories (4, object): ['purchase', 'product_cart', 'login', 'product_page'] - number of unique "details": 4 they are: [ 4.99 99.99 nan 9.99 499.99] - number of unique "date": 23 - number of unique "group": 2 they are: ['A', 'B'] Categories (2, object): ['A', 'B'] - number of unique "last_date": 15
display(events_filtered['event_dt'].min())
display(events_filtered['event_dt'].max())
Timestamp('2020-12-07 00:05:57')
Timestamp('2020-12-29 23:38:29')
Получили 25539 записей для 3481 уникального пользователя за период с 2020-12-07 по 2020-12-29.
Количество уникальных пользователей в логах меньше их количества в списке проводимых тестов. Выделим их и изучим, как они распределены между группами теста.
print(
'Количество новых пользователей, по которым отсутствуют записи в логах: {}'
.format(participants.count() - events_filtered['user_id'].nunique())
)
missed_users = (
tests[
(tests['user_id'].isin(participants))
& ~(tests['user_id'].isin(events_filtered['user_id']))
]['user_id'].unique()
)
missed_users_grouped = (
tests[
(tests['user_id'].isin(missed_users))
& (tests['ab_test'] == 'recommender_system_test')
]
.groupby('group', as_index=False)
.agg(missed_users=('user_id','count'))
)
missed_users_grouped['missed_users_distr'] = (
missed_users_grouped['missed_users'] / missed_users_grouped['missed_users'].sum()
)
users_grouped = users_grouped.merge(missed_users_grouped, on='group')
users_grouped['missed_users_part'] = users_grouped['missed_users'] / users_grouped['users']
users_grouped['active_users'] = users_grouped['users'] - users_grouped['missed_users']
users_grouped['active_users_distr'] = users_grouped['active_users'] / users_grouped['active_users'].sum()
users_grouped
Количество новых пользователей, по которым отсутствуют записи в логах: 2870
| group | users | users_distr | cros_users | cros_users_distr | cros_users_part | missed_users | missed_users_distr | missed_users_part | active_users | active_users_distr | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | A | 3634 | 0.57 | 921 | 0.57 | 0.25 | 1030 | 0.36 | 0.28 | 2604 | 0.75 |
| 1 | B | 2717 | 0.43 | 681 | 0.43 | 0.25 | 1840 | 0.64 | 0.68 | 877 | 0.25 |
2870 пользователей, зарегистрированных в тесте, не совершали целевых действий за период проведения теста. Эти пользователи распределены между тестовой и контрольной группой неравномерно: в тестовой группе 68% участников не совершали целевых действий.
В результате участники теста, совершавшие целевые действия, распределены между группами в соотношении 3/1: 2604 участника в контрольной группе и 877 - в тестовой.
Возможными причинами отсутствия событий могут быть как проблемы логирования, из-за которых события не записывались для некоторых пользователей, так и реальное отсутствие активности со стороны пользователей.
Ранее мы выяснили, что в период проведения теста проводилась маркетинговая кампания. Возможно, часть пользователей зарегистрировались в сервисе в результате ее проведения, но в дальнейшем отказались от использования сервиса.
Также, учитывая болшее количество неактивных пользователей именно в группе В (которой показывалась новая рекомендательная система), возможно, в сервисе имеются технические проблемы с ее работой, в результате чего у пользователей возникали проблемы с использованием сервиса.
Кроме того, ряд пользователей участвовали также в тесте нового интерфейса. Возможно, на их активность повлияли проблемы с использованием тестируемого интерфейса.
Оценим распределение событий по пользователям.
events_per_user = (
events_filtered
.groupby('user_id', as_index=False)
.agg(events=('event_dt', 'count'),
group=('group', 'first'))
)
print('Распределение числа событий по пользователям:\n')
print('По всем участникам теста:')
display(events_per_user['events'].describe())
print('В группе А:')
display(events_per_user[events_per_user['group'] == 'A']['events'].describe())
print('В группе В:')
display(events_per_user[events_per_user['group'] == 'B']['events'].describe())
Распределение числа событий по пользователям: По всем участникам теста:
count 3481.00 mean 7.34 std 4.75 min 1.00 25% 4.00 50% 6.00 75% 9.00 max 40.00 Name: events, dtype: float64
В группе А:
count 2604.00 mean 7.72 std 4.92 min 1.00 25% 4.00 50% 6.00 75% 9.00 max 40.00 Name: events, dtype: float64
В группе В:
count 877.00 mean 6.20 std 3.99 min 1.00 25% 3.00 50% 6.00 75% 8.00 max 24.00 Name: events, dtype: float64
plt.figure(figsize=(14, 4))
sns.boxenplot(data=events_per_user, x='events', y='group', order=['A', 'B'])
plt.title('Распределение числа пользовательских действий по группам теста', fontsize=(18))
plt.xlabel('Количество событий на одного пользователя', fontsize=(14))
plt.ylabel('Группа теста', fontsize=(14))
plt.show()
Медианное значение количества событий на одного пользователя равно шести в обеих группах теста. Половина всех пользователей в группе А совершает от 4 до 9 действий, в группе В - от 3 до 8.
По графикам видно, что активность пользователей в группе В смещена к меньшим значениям числа событий в сравнении с группой А. При этом наибольшее количество событий, совершенных пользователями в группе А равно 24, в группе В - 40.
То есть тестовая группа оказалась менее активной, чем контрольная.
Относительно большое количество пользователей совершили всего одно действие.
Также видим небольшое число участников теста с более чем 30 событиями. Посчитаем выборочные перцентили.
events_per_user['events'].quantile([0.01, 0.05, .95, .99])
0.01 1.0 0.05 2.0 0.95 16.0 0.99 24.0 Name: events, dtype: float64
Видими, что не менее 99% пользователей совершают от одного до 24 действий. Отфильтруем тестовые данные, удалив пользователей с неправдоподобно большим количеством действий, чтобы их активность не влияла на результаты анализа теста.
unreal_users = events_per_user[events_per_user['events'] > 24]['user_id']
events_filtered = events_filtered[~(events_filtered['user_id'].isin(unreal_users))]
Оценим распределение событий по дням.
events_filtered['date'].hist(figsize=(12, 3), bins=(23))
plt.title('Распределение числа событий по датам')
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.show()
События распределены неравномерно. Большая часть событий приходится на период с 14 по 24 декабря. При этом видим сильные всплески активности пользователей 14 декабря и 21 декабря.
Как мы помним, с 25 декабря проводилась маркетинговая кампания, однако активность пользователей в этот период не самая высокая и постепенно затухает. Можем предположить, что влияние маркетиговой акции на активность пользователей было несущественным.
Оценим распределение событий по дням в разбивке на группы теста.
fig = px.histogram(events_filtered,
x='date',
color='group',
range_x=[events_filtered['date'].min(), events_filtered['date'].max()],
title='Распределение количества событий в зависимости от группы теста',
nbins=23,
barmode='overlay')
fig.update_xaxes(title_text='Дата')
fig.update_yaxes(title_text='Количество событий')
fig.show()
Пользователи группы В совершали меньше действий. Однако, как мы помним, количество участников, попавших в группу В, меньше количества участников в группе А в три раза. Изучим распределение количества событий по дням относительно общего числа событий в каждой из групп.
event_per_date_grouped = (
events_filtered.groupby(['date', 'group'], as_index=False)
.agg(events_cnt=('event_name', 'count'))
.sort_values(['date', 'group'])
.reset_index(drop=True)
)
event_per_date_grouped['group_total_events'] = 0
for group in ['A', 'B']:
event_per_date_grouped.loc[event_per_date_grouped['group']==group, 'group_total_events'] \
= event_per_date_grouped.loc[event_per_date_grouped['group']==group, 'events_cnt'].sum()
event_per_date_grouped['%%'] = (
(event_per_date_grouped['events_cnt'] / event_per_date_grouped['group_total_events'] * 100).round(2)
)
fig = px.bar(
event_per_date_grouped,
x='date', y='%%', color='group',
)
fig.update_layout(
title='Распределение событий по датам в % от общего числа событий в каждой из групп теста',
xaxis_title='Дата',
yaxis_title='% событий на дату'
)
fig.show()
В первую неделю теста пользователи группы А совершали больше действий относительно их общего числа за весь период теста, чем пользователи группы В. Наиболее резкое повышение активности произошло 14 декабря в группе A. Затем в обех группах до 21 декабря шло плавное повышение активности. 21 декабря видим резкий всплеск активности, а 22 декабря произошел резкий спад числа событий в обеих группах (но особенно - в группе В) с последюущим постепенным снижением активности к концу теста.
Изучим, какие события есть в логах и как часто они встречаются, отсортируем их по частоте.
events_per_event = (
events_filtered.groupby('event_name')
.agg(events=('event_dt', 'count'))
.sort_values('events', ascending=False)
.reset_index()
)
events_per_event
| event_name | events | |
|---|---|---|
| 0 | login | 11380 |
| 1 | product_page | 6968 |
| 2 | purchase | 3335 |
| 3 | product_cart | 3228 |
Наибольшее количество событий - вход в сервис (11380), что логично. Далее по общему числу событий идет просмотр карточки товара (6968). Что примечательно, количество покупок (3335) превышает количество просмотров корзины (3228). Видимо, в сервисе предусмотрена возможность оплвты товара без захода на страницу корзины.
Посчитаем, сколько пользователей совершали каждое из этих событий. Посчитаем долю пользователей, которые хоть раз совершили каждое из событий. Отсортируем события по числу пользователей.
users_per_event = (
events_filtered.groupby('event_name')
.agg(users=('user_id', 'nunique'))
.sort_values('users', ascending=False)
.reset_index()
)
users_per_event['users_%'] = users_per_event['users'] / events_filtered['user_id'].nunique() * 100
users_per_event
| event_name | users | users_% | |
|---|---|---|---|
| 0 | login | 3461 | 99.97 |
| 1 | product_page | 2160 | 62.39 |
| 2 | purchase | 1067 | 30.82 |
| 3 | product_cart | 1014 | 29.29 |
Видим, что как и с количеством событий, количество пользователей, совершавших покупки, больше количества пользователей, заходивших в корзину. Также видим, что для 0.03% пользователей отсутствует запись о входе в приложение. Возможно, имеет место ошибка логирования.
Очевидно, что воронка выглядит так:
'login'(вход в сервис) ->'product_page' (страница товара) ->'product_cart' (страница корзины) ->'purchase' (покупка).events_per_event['event_name'] = (
pd.Categorical(
events_per_event['event_name'],
['login', 'product_page', 'product_cart', 'purchase']
)
)
users_per_event['event_name'] = (
pd.Categorical(
users_per_event['event_name'],
['login', 'product_page', 'product_cart', 'purchase']
)
)
events_per_event = events_per_event.sort_values('event_name')
users_per_event = users_per_event.sort_values('event_name')
display(events_per_event)
display(users_per_event)
| event_name | events | |
|---|---|---|
| 0 | login | 11380 |
| 1 | product_page | 6968 |
| 3 | product_cart | 3228 |
| 2 | purchase | 3335 |
| event_name | users | users_% | |
|---|---|---|---|
| 0 | login | 3461 | 99.97 |
| 1 | product_page | 2160 | 62.39 |
| 3 | product_cart | 1014 | 29.29 |
| 2 | purchase | 1067 | 30.82 |
Построим воронку по количеству событий на каждом этапе.
fig = go.Figure(
go.Funnel(
x=events_per_event['events'],
y=events_per_event['event_name'],
)
)
fig.show()
Построим воронку событий сервиса по количеству пользователей на каждом этапе.
fig = go.Figure(
go.Funnel(
x=users_per_event['users'],
y=users_per_event['event_name'],
)
)
fig.show()
Изучем воронку событий в разрезе групп.
events_funnel_grouped = (
events_filtered.groupby(['group', 'event_name'])
.agg(events=('user_id', 'nunique'))
.sort_values(['event_name', 'group'])
.reset_index()
)
events_funnel_grouped['event_name'] = (
pd.Categorical(
events_funnel_grouped['event_name'],
['login', 'product_page', 'product_cart', 'purchase']
)
)
events_funnel_grouped = events_funnel_grouped.sort_values(['event_name', 'group'])
events_funnel_grouped['conversion'] = 0
for group in ['A', 'B']:
events_funnel_grouped.loc[events_funnel_grouped['group'] == group, 'conversion'] = (
events_funnel_grouped.loc[events_funnel_grouped['group'] == group, 'events'] /
events_filtered.loc[events_filtered['group'] == group, 'user_id'].nunique()
) * 100
events_funnel_grouped
| group | event_name | events | conversion | |
|---|---|---|---|---|
| 0 | A | login | 2701 | 100.00 |
| 1 | B | login | 1182 | 99.92 |
| 4 | A | product_page | 1729 | 64.01 |
| 5 | B | product_page | 684 | 57.82 |
| 2 | A | product_cart | 800 | 29.62 |
| 3 | B | product_cart | 328 | 27.73 |
| 6 | A | purchase | 846 | 31.32 |
| 7 | B | purchase | 333 | 28.15 |
fig = px.funnel(events_funnel_grouped, x='events', y='event_name', color='group')
fig.show()
funnel_grouped = (
events_funnel_grouped.loc[events_funnel_grouped['group'] == 'A', ['event_name', 'events', 'conversion']]
.merge(
events_funnel_grouped.loc[events_funnel_grouped['group'] == 'B', ['event_name', 'events', 'conversion']],
on='event_name', suffixes=('_a', '_b')
)
)
funnel_grouped['conversion_step_a'] = (
funnel_grouped['events_a'].div(funnel_grouped['events_a'].shift(1)).fillna(1) * 100
)
funnel_grouped['conversion_step_b'] = (
funnel_grouped['events_b'].div(funnel_grouped['events_b'].shift(1)).fillna(1) * 100
)
funnel_grouped
| event_name | events_a | conversion_a | events_b | conversion_b | conversion_step_a | conversion_step_b | |
|---|---|---|---|---|---|---|---|
| 0 | login | 2701 | 100.00 | 1182 | 99.92 | 100.00 | 100.00 |
| 1 | product_page | 1729 | 64.01 | 684 | 57.82 | 64.01 | 57.87 |
| 2 | product_cart | 800 | 29.62 | 328 | 27.73 | 46.27 | 47.95 |
| 3 | purchase | 846 | 31.32 | 333 | 28.15 | 105.75 | 101.52 |
Наибольший процент пользователей теряется на этапе перехода со страницы товара на страницу корзины (более 50% от количества на предыдущем этапе). При этом количество покупателей превышает количество пользователей, просматривающих страницу корзины. Очевидно, что покупку можно совершить, минуя этот этап. Возможно, его полное исключение также может повывсить итоговую конверсию в сервисе.
От входа в приложение до оплаты доходят около 30% пользователей. Общая конверсия в покупку в группе В ниже, чем в группе А (28,15% против 31,32%).
Конверсия в группе В ниже почти на всех этапах воронки, кроме этапа перехода со страницы товара на страницу корзины - здесь доля пользователей, переходящих из одного этапа воронки в другой чуть выше у групы В (47,95% против 46,27%).
Возможно, новая рекомендательная система мотивирует пользователей чаще добавлять товар в корзину, в том числе, не заходя на страницу самого товара. Однако, она не доводит пользователей до покупки.
Прежде чем приступать к тестированию статистических гипотез, мы должны учесть, что покупка товара возможна без захода на страницу корзины. А так же то, что пользователи распределены между группами теста очень неравномерно.
Ожидаемый эффект от внедрения новой рекомендательно системы - улучшение конверсии на каждом этапе не менее, чем на 10%:
Мы выяснили, что по большнству метрик группа В показала ухудшение результата.
Перейдем к проверке статистической значимости различий между группами теста. В рассматриваемом тесте будем проверять гипотезу о равенстве пропорций. Для этого применим z-тест.
Всего будем иметь три попарных сравнения для каждого из этапов воронки (просмотр карточки товара, просмотр корзины, покупка). Следовательно, для всех тестов введем поправку для критерия значимости с коэффициентом равным 3. Применим для расчетов метод Шидака, сохраняющий групповую вероятность ошибки первого рода меньше альфа, но повышающий мощность теста.
Обозначим нулевую и альтернативную гипотезы для каждого из попарных сравнений:
Н0: доли посетителей на каждом из этапов воронки между группами теста одинаковы.
Н1: доли посетителей на каждом из этапов воронки между группами теста различаются.
Уровень значимости примем равным 5%.
alpha = 0.05
ratio = 3
Для удобства оформим расчеты в виде функции.
def z_test(sample1, sample2, event, alpha, ratio):
# скорректированный уровень значимости
alpha_corr = 1 - (1 - alpha) ** (1 / ratio)
# размеры сравниваемых групп
trials = np.array([sample1['user_id'].nunique(), sample2['user_id'].nunique()])
# число пользователей, дошедших до события event в каждой из групп
successes = np.array([sample1[sample1['event_name'] == event]['user_id'].nunique(),
sample2[sample2['event_name'] == event]['user_id'].nunique()])
# пропорции успехов в группах
p1 = successes[0] / trials[0]
p2 = successes[1] / trials[1]
# пропорции успехов в комбинированном датасэте
p_comb = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасэтах
difference = p1 - p2
# считаем z-статистику
z_value = difference / mth.sqrt(p_comb * (1 - p_comb) * (1 / trials[0] + 1 / trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2 # двусторонний тест
print('Событие:', event)
print('p-значение: ', p_value)
if p_value < alpha_corr:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными',
)
print()
Проверим, есть ли между группами теста статистически значимая разница в конверсии на обозначенных этапах.
for event in ['product_page', 'product_cart', 'purchase']:
z_test(
events_filtered[events_filtered['group'] == 'A'],
events_filtered[events_filtered['group'] == 'B'],
event=event, alpha=alpha, ratio=ratio
)
Событие: product_page p-значение: 0.00024961296583159154 Отвергаем нулевую гипотезу: между долями есть значимая разница Событие: product_cart p-значение: 0.23178904667542177 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: purchase p-значение: 0.047778911591688455 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Результаты теста.
Выявлено статистически значимое различие между конверсиями в карточку товара. Низкое р-значение указывает на то, что снижение конверсии в группе В не случайно, а является результатом применения тестируемой рекомендательной системы. Из-за тестируемой рекомендательной системы пользователи резе просматривают карточки товаров.
Отсутствует статистически значимое различие между конверсиями в переход к корзине и в оплате покупки. Следовательно, применение тестируемой рекомендательной системы не приводит к изменению в конверсии на этих этапах.
В тестовую выборку попали 350 пользователей из нецелевого региона. Следует изучить причину.
Временной интервал в пользовательских логах не соответствует ТЗ и ограничен данными до 2020-12-30. Однако, последующий анализ активности показал, что количество событий начало снижаться 22 декабря и к 29 декабря достигло минимальных значений.
В период проведения теста проводилась маркетинговая кампания (с 2020-12-25 по 2021-01-03). Однако, анализ активности пользователей в рассматриваемый период не указывает на наличие сильного влияния маркетинговой кампании.
1602 участника теста также попали в выборку другого теста. Однако, эти пользователи распределены между группами изучаемого теста равномерно, а, следовательно, одинаково влияют на обе группы теста.
Разделение на группы проведено корректно: пересечений между группами нет, а пользователи разделены в соотношении 57/43.
2870 пользователей, отобранных для эксперимента, не совершали целевых действий за период проведения теста. Они распределились неравномерно: 64% неактивных участников оказались в группе В. В результате участники теста, совершавшие целевые действия, распределились между группами в соотношении 3/1: 2604 участника в контрольной группе и 877 - в тестовой.
Медианное количество событий на одного пользователя равно шести в обеих группах теста. Половина всех пользователей в группе А совершает от 4 до 9 действий, в группе В - от 3 до 8. Активность пользователей в группе В смещена к меньшим значениям в сравнении с группой А. То есть тестовая группа оказалась менее активной, чем контрольная.
В первую неделю теста пользователи группы А совершали больше действий относительно их общего числа за весь период теста, чем пользователи группы В. 14 декабря произошло резкое повышение активности, особенно - в группе A. Затем в обех группах до 21 декабря шло плавное повышение активности. 21 декабря случился резкий всплеск активности, а 22 декабря произошел резкий спад числа событий в обеих группах (но особенно - в группе В) с последюущим постепенным снижением активности к концу теста.
Наибольший процент пользователей теряется на этапе перехода со страницы товара на страницу корзины (более 50% от количества на предыдущем этапе). При этом количество покупателей превышает количество пользователей, просматривающих страницу корзины. Очевидно, что покупку можно совершить, минуя этот этап. Возможно, его полное исключение также может повывсить итоговую конверсию в сервисе.
От входа в сервис до оплаты доходят около 30% пользователей. Общая конверсия в покупку в группе В ниже, чем в группе А (28,15% против 31,32%).
Конверсия в группе В ниже почти на всех этапах воронки, кроме этапа перехода со страницы товара на страницу корзины - здесь доля пользователей, переходящих из одного этапа воронки в другой чуть выше у групы В (47,95% против 46,27%).
Выявлено статистически значимое различие между конверсиями в карточку товара. Низкое р-значение указывает на то, что снижение конверсии в группе В не случайно, а является результатом применения тестируемой рекомендательной системы. Из-за тестируемой рекомендательной системы пользователи реже просматривают карточки товаров.
Отсутствует статистически значимое различие между конверсиями в переход к корзине и в оплате покупки. Следовательно, применение тестируемой рекомендательной системы не приводит к изменению в конверсии на этих этапах.
Результаты теста указывают на ухудшение показателей при применении новой рекомендательной системы.